'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import * as signalR from '@microsoft/signalr'; import { fetchApi } from '@/lib/utils/client'; import { useStudioContext } from '@/app/studio/context'; import { useSignalRContext } from '@/contexts/signalrProvider'; import type { CrewMemberItem, CrewMemberListResponse, SearchResultItem, SearchMemberResponse } from '@/types/response/crew/member'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'; import { Info } from 'lucide-react'; function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) { return ; } const EMPTY_MEMBER_FORM = { nickname: '', role: '', sortOrder: 0, isActive: true }; type Props = { crewID: number; }; export default function CrewMembersTab({ crewID }: Props) { const { channelID } = useStudioContext(); const { appConnection, appConnected } = useSignalRContext(); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); // 초대 코드 const [inviteCode, setInviteCode] = useState(null); const [generatingCode, setGeneratingCode] = useState(false); // 회원 검색 const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [showSearch, setShowSearch] = useState(false); const searchTimer = useRef|null>(null); // 크루원 편집 모달 const [editModal, setEditModal] = useState<{ open: boolean; member: CrewMemberItem|null }>({ open: false, member: null }); const [editForm, setEditForm] = useState(EMPTY_MEMBER_FORM); const [saving, setSaving] = useState(false); const fetchMembers = useCallback(async () => { try { const res = await fetchApi(`/api/studio/crew/member/list/${crewID}`); setMembers(res.data?.list ?? []); } catch { } finally { setLoading(false); } }, [crewID]); useEffect(() => { fetchMembers(); }, [fetchMembers]); // SignalR 멤버 변경 이벤트 수신 — 운영자 그룹(crew:{crewID}) 구독 + 200ms debounce refetch. // 다중 이벤트(예: 일괄 작업) 시 한 번만 refetch 하도록 합침. useEffect(() => { if (!appConnection || !appConnected) { return; } if (appConnection.state !== signalR.HubConnectionState.Connected) { return; } let refetchTimer: ReturnType|null = null; const scheduleRefetch = () => { if (refetchTimer) { clearTimeout(refetchTimer); } refetchTimer = setTimeout(() => { fetchMembers(); }, 200); }; const handler = (_payload: unknown) => { scheduleRefetch(); }; appConnection.on('ReceiveCrewMemberChange', handler); appConnection.invoke('JoinCrew', crewID).catch(err => { console.warn('[crew] JoinCrew failed:', err); }); return () => { if (refetchTimer) { clearTimeout(refetchTimer); } appConnection.off('ReceiveCrewMemberChange', handler); if (appConnection.state === signalR.HubConnectionState.Connected) { appConnection.invoke('LeaveCrew', crewID).catch(() => {}); } }; }, [appConnection, appConnected, crewID, fetchMembers]); // SignalR presence 구독 — 멤버 목록 변경 시 재구독, 이벤트 수신 시 isOnline 갱신. // members 가 빈 배열일 때는 구독할 대상이 없어 early return — 첫 fetchMembers 완료 후 의존성 변화로 재실행되어 정상 구독. const memberIDsKey = members.map(m => m.memberID).join(','); useEffect(() => { if (!appConnection || !appConnected) { return; } if (appConnection.state !== signalR.HubConnectionState.Connected) { return; } const memberIDs = Array.from(new Set(members.map(m => m.memberID))).filter(id => id > 0); if (memberIDs.length === 0) { return; } const handler = (memberID: number, isOnline: boolean) => { setMembers(prev => prev.map(m => m.memberID === memberID ? { ...m, isOnline } : m)); }; appConnection.on('ReceivePresenceChange', handler); appConnection.invoke('SubscribePresence', memberIDs).catch(err => { console.warn('[presence] SubscribePresence failed:', err); }); return () => { appConnection.off('ReceivePresenceChange', handler); if (appConnection.state === signalR.HubConnectionState.Connected) { appConnection.invoke('UnsubscribePresence', memberIDs).catch(() => {}); } }; // memberIDsKey 가 동일하면 effect 재실행 안됨 (불필요한 sub/unsub 방지). // members 자체를 의존성에 넣지 않는 이유: members 가 같은 ID 집합이어도 객체 reference 가 바뀌면 매번 재실행되므로. // eslint-disable-next-line react-hooks/exhaustive-deps }, [appConnection, appConnected, memberIDsKey]); // 회원 검색 (디바운스) useEffect(() => { if (searchTimer.current) { clearTimeout(searchTimer.current); } if (searchQuery.trim().length < 2) { setSearchResults([]); return; } searchTimer.current = setTimeout(async () => { try { const res = await fetchApi( `/api/studio/crew/member/search?channelID=${channelID}&q=${encodeURIComponent(searchQuery)}&crewID=${crewID}` ); setSearchResults(res.data?.list ?? []); } catch {} }, 300); return () => { if (searchTimer.current) { clearTimeout(searchTimer.current); } }; }, [searchQuery, channelID, crewID]); const handleAddMember = async (item: SearchResultItem) => { try { await fetchApi('/api/studio/crew/member/add', { method: 'POST', body: { crewID, targetMemberID: item.memberID, nickname: item.channelName ?? item.name ?? item.email, sortOrder: members.length + 1 } }); setSearchQuery(''); setSearchResults([]); setShowSearch(false); fetchMembers(); } catch (err: unknown) { alert(err instanceof Error ? err.message : '추가에 실패했습니다.'); } }; const handleRemove = async (memberID: number) => { if (!confirm('이 크루원을 삭제하시겠습니까?')) { return; } try { await fetchApi(`/api/studio/crew/member/${memberID}`, { method: 'DELETE' }); fetchMembers(); } catch (err: unknown) { alert(err instanceof Error ? err.message : '삭제에 실패했습니다.'); } }; const openEdit = (member: CrewMemberItem) => { setEditForm({ nickname: member.nickname, role: member.role ?? '', sortOrder: member.sortOrder, isActive: member.isActive }); setEditModal({ open: true, member }); }; const handleUpdate = async () => { if (!editModal.member) { return; } setSaving(true); try { await fetchApi(`/api/studio/crew/member/${editModal.member.id}`, { method: 'PUT', body: { crewMemberID: editModal.member.id, nickname: editForm.nickname, role: editForm.role || null, sortOrder: editForm.sortOrder, isActive: editForm.isActive } }); setEditModal({ open: false, member: null }); fetchMembers(); } catch (err: unknown) { alert(err instanceof Error ? err.message : '수정에 실패했습니다.'); } finally { setSaving(false); } }; const handleGenerateCode = async () => { setGeneratingCode(true); try { const res = await fetchApi<{ inviteCode: string }>('/api/studio/crew/invite/generate', { method: 'POST', body: { crewID } }); setInviteCode(res.data?.inviteCode ?? null); } catch (err: unknown) { alert(err instanceof Error ? err.message : '코드 생성에 실패했습니다.'); } finally { setGeneratingCode(false); } }; const copyCode = () => { if (inviteCode) { navigator.clipboard.writeText(inviteCode); alert('복사되었습니다.'); } }; if (loading) return

준비 중...

; return ( <> {/* 초대 코드 */}
초대 코드
{inviteCode ? ( <> ) : ( )}
{/* 크루원 관리 */}

크루원 ({members.length}명)

{showSearch && (
setSearchQuery(e.target.value)} autoComplete='off'/> {searchResults.length > 0 && (
{searchResults.map(item => ( ))}
)}
)}
{members.length === 0 ? ( ) : members.map(m => { const isAccepted = m.inviteStatus === 'Accepted'; const statusLabel = m.inviteStatus === 'Accepted' ? (m.isActive ? '활성' : '비활성') : m.inviteStatus === 'Pending' ? '초대 중' : m.inviteStatus === 'Declined' ? '거절됨' : '만료'; const statusModifier = m.inviteStatus === 'Accepted' ? (m.isActive ? 'active' : 'inactive') : m.inviteStatus === 'Pending' ? 'pending' : 'expired'; return ( ); })}
순서 별명 역할 채널 접속 상태 가입일 작업
등록된 크루원이 없습니다.
{m.sortOrder} {m.nickname} {m.nickname} {m.role ?? '-'} {m.channelName ?? '-'} {statusLabel} {new Date(m.joinedAt).toLocaleDateString('ko-KR')}
{isAccepted ? ( <> ) : ( {m.inviteStatus === 'Pending' ? '응답 대기' : '-'} )}
{/* 크루원 편집 모달 */} { if (!open) setEditModal({ open: false, member: null }); }}> 크루원 수정
별명 setEditForm(f => ({ ...f, nickname: e.target.value }))} />
setEditForm(f => ({ ...f, role: e.target.value }))} placeholder="예: 게이머, MC" />
setEditForm(f => ({ ...f, sortOrder: Number(e.target.value) }))} />
setEditForm(f => ({ ...f, isActive: !!v }))} />
); }